04. App Widget问题总结

UI 问题

图片相关

支持 url 加载图片吗?

不行,可以使用 app 本地图片,或者 app 下载好图片 bitmap,然后 set

支持的图片格式,gif,webp?

支持 PNG, WEBP 等

不支持动图 gif

图片尺寸问题

2:1 的图片,需要展示在 4x2 的小组件中,如何保证图片比例不拉伸填充满小组件

解决: 设置图片 adjustViewBounds=true

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="@dimen/app_search_widget_height"
    android:orientation="vertical"
    tools:layout_width="match_parent">

    <ImageView
        android:id="@+id/iv_main_bg"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:adjustViewBounds="true"
        android:background="@drawable/shape_app_widget_promotion_main_doudi_bg"
        android:src="@drawable/ic_appwidget_promotion_doudi"
        tools:ignore="ContentDescription" />
</FrameLayout>

代码:

private suspend fun handleNoDataFromLocal(
	context: Context,
	remoteViews: RemoteViews
) {
	remoteViews.apply {
		// 主背景圆角处理
		setRoundedImageBitmap(
			context = context,
			viewId = R.id.iv_main_bg,
			bgResource = R.drawable.ic_appwidget_promotion_doudi,
			width = width(),
			height = height(),
			radius = appWidgetBgRadius
		)
		// 背景deeplink
		setPromotionItemOnclickPending(
			context = context,
			viewId = R.id.iv_main_bg,
		)
	}
}

图片下载问题,bitmap 回收问题

通过 Fresco 下载图片后,要注意 bitmap 回收的问题

private suspend fun loadImageSuspend(url: String, imageCorner: Float, @DrawableRes maskResId: Int): Bitmap {
	val postProcessor = if (imageCorner != 0f) {
		RoundedCornerPostprocessor(imageCorner, maskResId)
	} else null
	return suspendCancellableCoroutine { cont ->
		val loadConfig = SImageLoader.LoadConfigTemplate.DEFAULT.getConfig().copy(
			isNeedCut = true,
			customBitmapProcessor = postProcessor,
			onImageLoadListener = object : OnImageLoadListener {
				override fun onFailure(url: String, throwable: Throwable) {
					cont.resumeWithException(throwable)
				}

				override fun onImageDecodeSuccess(url: String, bitmap: Bitmap) {
					// 图片decode完成,这里需要copy一份,否则协程回调回去后,bitmap可能已经被fresco内部recycled。
					cont.resume(bitmap.copy(bitmap.config, true))
				}
			}
		)
		SImageLoader.load(
			url = url,
			loadConfig = loadConfig
		)
	}
}

图片 OOM 问题

解决 1

  1. 后端下发的图片尺寸小点
  2. 下载的图片如果过大,容易 OOM,如果是 Fresco,指定 resizeOptions

示例代码:

suspend fun fetchImageWithSize(
    imgUrl: String?,
    imageCorner: Float = 0f,
    scaleType: ScalingUtils.ScaleType = ScalingUtils.ScaleType.FIT_XY,
    resizeWith: Int,
    resizeHeight: Int,
): Bitmap? =
    runCatching {
        suspendCancellableCoroutine<Bitmap?> { continuation ->
            if (imgUrl == null) {
                continuation.resume(null)
                return@suspendCancellableCoroutine
            }

            val uri = Uri.parse(imgUrl)

            val imageRequestBuilder = ImageRequestBuilder.newBuilderWithSource(uri)

            if (resizeWith != 0 && resizeHeight != 0) {
                imageRequestBuilder.resizeOptions = ResizeOptions(resizeWith, resizeHeight)
            }

            val imageRequest = imageRequestBuilder.build()

            val imagePipeline = Fresco.getImagePipeline()
            val dataSource = imagePipeline.fetchDecodedImage(imageRequest, AppContext.application)

            dataSource.subscribe(
                object : BaseBitmapDataSubscriber() {
                    override fun onNewResultImpl(bitmap: Bitmap?) {
                        if (bitmap != null && !bitmap.isRecycled) {
                            continuation.resume(bitmap.copy(bitmap.config, true))
                        } else {
                            continuation.resume(null)
                        }
                    }

                    override fun onFailureImpl(dataSource: DataSource<CloseableReference<CloseableImage>>) {
                        continuation.resume(null)
                    }
                },
                CallerThreadExecutor.getInstance()
            )

            continuation.invokeOnCancellation {
                dataSource.close()
            }
        }
    }.onFailure {
        L.e("fetchImageAsBitmap failed ${it.message}", it)
    }.getOrNull()

解决 2

如果通过方法 1 还没有彻底解决,那么可采用局部更新 partiallyUpdateAppWidget() 下载一张图片就更新,避免一次将多个 bitmap 全部更新

App Widget 布局适配

如何确定 widget 尺寸

cell 的宽度和高度以及应用于小部件的自动 margin 的大小可能因设备而异。考虑到您想要占用的网格单元数,请使用下表粗略估计典型 5x4 网格手机中小部件的最小尺寸(Piexl 4 手机):

Number of cells (width x height) Available size in portrait mode (dp) Available size in landscape mode (dp)
1 x 1 57 x 102 dp 127 x 51 dp
2 x 1 130 x 102 dp 269 x 51 dp
3 x 1 203 x 102 dp 412 x 51 dp
4 x 1 276 x 102 dp 554 x 51 dp
5 x 1  5×1 349 x 102 dp 697 x 51 dp
5 x 2 349 x 220 dp 697 x 117 dp
5 x 3 349 x 337 dp 697 x 184 dp
5 x 4 349 x 455 dp 697 x 250 dp
n x m (73 n - 16) x (118 m - 16) (142 n - 15) x (66 m - 15)

Android 11 及以下 cell 计算公式(粗略)

示例 1:4 x 2

布局适配问题

问题 1:(一加、Pixel 6、三星 A 51)
不同的手机,高度不一样,导致 UI 适配很差

淘宝方案:

固定宽高尺寸,从上往下摆,下面多余的部分空白

竖屏(等比例):

横屏(等比例):

方案 1:固定尺寸

和淘宝一样,固定宽高尺寸,过宽的部分空白。内容是多少,圆角就裁剪多少

下面是 三星 A51 手机效果

优点:

缺点:

方案 2:等比例,LinearLayout weight

尽可能的用 LinearLayout 的 weight,包括控件的大小,margin;一定程序上可以适配,但开发量大

优点:

缺点:

方案 3:基于 sw 适配(推荐)

三星 A 51 适配后的效果(竖屏),上面的是未适配的,下面的基于 sw 适配后的效果:

横屏:

疑问:
appwidget-provider 中的 minWidget 是否需要基于 sw 来做适配?

优点:

缺点:

多套布局

横屏适配

Google 的天气预报 Widget 在转屏后布局依然很好,而且看样子像是一个新的布局文件,于是在做应用时也想抓到转屏事件,然后加载一个新的 layout 文件。可是测试发现 AppWidgetProvider 的几个方法都接受不到转屏事件,于是在网上搜啊搜啊,终于发现,要解决这个问题,至于要在工程中新建两个文件夹就能解决,分别是:

分别写两个同名的布局文件,当然内容要根据横竖屏加以区分,放到两个文件夹中,然后像往常一样使用这个布局文件就行了,Android 会根据横竖屏情况分别加载这两个文件夹中的布局文件。

多套布局适配
Android12 之前

在 Android 12 之前如果想适配不同宽高下显示不同布局的话需要重写下 onAppWidgetOptionsChanged() 方法,然后从中获取到当前小部件的最小宽高,根据宽高的不同就可以进行布局适配了。

override fun onAppWidgetOptionsChanged(
    context: Context,
    appWidgetManager: AppWidgetManager,
    appWidgetId: Int,
    newOptions: Bundle
) {
    super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions)
    // See the dimensions and
    val options = appWidgetManager.getAppWidgetOptions(appWidgetId)
    // 获取小部件最小的宽高
    val minWidth = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH)
    val minHeight = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT)
    // 计算小部件的占的格数
    val rows: Int = getCellsForSize(minHeight)
    val columns: Int = getCellsForSize(minWidth)
    Log.e("rows:$rows   columns:$columns")
    updateAppWidget(context, appWidgetManager, appWidgetId, rows, columns)
}
/**
 * 返回给定大小的小部件所需的单元格数。 根据官方文档中写的计算小部件格数的方法进行定义的
 * @param size 以 dp 为单位的小部件大小。
 * @return 单元格数量的大小。
 */
fun getCellsForSize(size: Int): Int {
    var n = 2
    while (70 * n - 30 < size) {
        ++n
    }
    return n - 1
}

// 或者下面的
override fun onAppWidgetOptionsChanged(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int, newOptions: Bundle) {
    super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions)
    val options = appWidgetManager.getAppWidgetOptions(appWidgetId)

    val minWidth = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH)
    val minHeight = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT)

    val rows: Int = getWidgetCellsM(minHeight)
    val columns: Int = getWidgetCellsN(minWidth)
    updateAppWidget(context, appWidgetManager, appWidgetId, rows, columns)
}

fun getWidgetCellsN(size: Int): Int {
    var n = 2
    while (73 * n - 16 < size) {
        ++n
    }
    return n - 1
}

fun getWidgetCellsM(size: Int): Int {
    var m = 2
    while (118 * m - 16 < size) {
        ++m
    }
    return m - 1
}
// 其中的计算公式,n x m:(73n-16)x(118m-16)就是文档中提到的算法。

但是这种方案有一个致命的问题,那就是不同的 ROM 的计算方式完全不一样,有可能在 Vivo 上一个格子的高度只有 80,但是在 Pixel 中,一个格子就是 100,所以,在不同的设备上显示的 n x m 不一样,也是很正常的事。

也正是因为这样的问题,如果不是只在 Android 12+ 的设备上使用,那么通常都是固定好 Widget 的大小,避免使用动态布局,这也是没办法的权衡之举。

Android12 之后

在 Android 12 之后,可以通过响应式布局来进行适配,首先需要创建一组不同尺寸的布局,然后调用 updateAppWidget() 函数,并传入一组布局,当小部件尺寸发生变化时,系统会自动更改布局。

internal fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int, widgetData: AppWidgetData) {
    val views41 = RemoteViews(context.packageName, R.layout.new_app_widget41).also { updateView(it, context, appWidgetId, widgetData) }
    val views42 = RemoteViews(context.packageName, R.layout.new_app_widget42).also { updateView(it, context, appWidgetId, widgetData) }
    val views21 = RemoteViews(context.packageName, R.layout.new_app_widget21).also { updateView(it, context, appWidgetId, widgetData) }
    val viewMapping: Map<SizeF, RemoteViews> = mapOf(
        SizeF(180f, 110f) to views21,
        SizeF(270f, 110f) to views41,
        SizeF(270f, 280f) to views42
    )
    appWidgetManager.updateAppWidget(appWidgetId, RemoteViews(viewMapping))
}

private fun updateView(remoteViews: RemoteViews, context: Context, appWidgetId: Int, widgetData: AppWidgetData) {
    remoteViews.setTextViewText(R.id.xxx, widgetData.xxx)
}

它的核心就是 RemoteViews(viewMapping),通过这个就可以动态适配当前用户选择的尺寸。

报错: java.lang.RuntimeException: RemoteViews specifying separate layouts for orientation or size cannot be modified. Instead, fully configure each layouts individually before constructing the combined layout.

原因: Android12 不同尺寸定义了不同的布局,更新布局的元素时,需要在对应尺寸的布局上更新,
解决:

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
	val normalRemoteViews =
		RemoteViews(
			context.packageName,
			R.layout.layout_app_widget_logistics_2x2_local_data,
		).apply {
			// 需要在这更新
			handleNotDataFromLocal(context, this, "viewMapping-normal")
		}
	val smallRemoteViews = RemoteViews(
		context.packageName,
		R.layout.layout_app_widget_logistics_2x2_local_data_133x133
	).apply {
		// 需要在这更新
		handleNotDataFromLocal(context, this, "viewMapping-small")
	}
	val viewMapping: Map<SizeF, RemoteViews> = mapOf(
		SizeF(155.dp.toFloat(), 155.dp.toFloat()) to normalRemoteViews,
		SizeF(133.dp.toFloat(), 133.dp.toFloat()) to smallRemoteViews,
	)
	RemoteViews(viewMapping) // 不能用这个RemoteViews更新,否则报错
} else {
	RemoteViews(context.packageName, singleLayoutId()).apply {
		handleNotDataFromLocal(context, this, "single")
	}
}

5 格手机 cell 占不满问题

问题: targetCellWidth 如果写死成 4 格,但有的手机是 5 格,会出现有 1 格空着的

解决:xml-v31 中,minWidthtargetCellWidth 都填上,targetCellWidth 填成 5,这样在 5 格的手机可以占满宽度,在 4 格的手机根据 minWidth 适配成 4 格

<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
    android:description="@string/appwidget_search_tool_description"
    android:initialLayout="@layout/layout_app_widget_search_tool"
    android:minWidth="300dp"
    android:minHeight="120dp"
    android:previewImage="@drawable/appwidget_preview_search_tool"
    android:previewLayout="@layout/layout_app_widget_search_tool"
    android:targetCellWidth="4"
    android:targetCellHeight="2"
    android:updatePeriodMillis="1800000"
    android:widgetCategory="home_screen" />

minWidth 适配及如何选择?

minWidth 太大

minWidth 如何定义?

5x2 如何写 minWidthminHeight

android:minWidth="300dp"
android:minHeight="120dp"
android:targetCellWidth="5"  
android:targetCellHeight="2"

问题: 在 Pixel4a 手机,Android11,会适配成 5x3 导致 height 过高,留白太多。
解决: minHeight 改为 100dp

UI 适配问题

Widget 可以实现渐变、圆角

存在的问题:

背景问题

ImageView

class ExampleAppWidgetProvider : AppWidgetProvider() {
    override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
        for (appWidgetId in appWidgetIds) {
            updateAppWidget(context, appWidgetManager, appWidgetId)
        }
    }

    companion object {
        fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int) {
            val views = RemoteViews(context.packageName, R.layout.example_appwidget)

            // 使用 Bitmap 更新 ImageView
            CoroutineScope(Dispatchers.Main).launch {
                // 从网络加载或资源文件加载 Bitmap
                val bitmap: Bitmap = getBitmapFromResources(context, R.drawable.ic_launcher_foreground)
                // val bitmap: Bitmap = getBitmapFromUrl(context, "https://example.com/image.png") // 示例从网络加载

                // 更新 ImageView
                views.setImageViewBitmap(R.id.widget_imageview, bitmap)

                // 更新 App Widget
                appWidgetManager.updateAppWidget(appWidgetId, views)
            }
        }
    }
}

其他控件

如果是 LinearLayout 调用 setImageViewBitmap 会报错:

android. Widget. RemoteViews $ActionException: android.widget.RemoteViews$ ActionException: view: android. Widget. LinearLayout doesn't have method: setImageBitmap (class android. Graphics. Bitmap)

在 App Widget 中,确实无法通过 RemoteViews 直接调用 setBackgroundBitmap 等方法来设置 LinearLayout 的背景 Bitmap。RemoteViews 对于其支持的方法有严格的限制。

因此,您需要一个替代方案以在 LinearLayout 上设置背景图像。这个替代方案通常包括将 ImageView 作为 LinearLayout 的背景,然后通过更新这个 ImageView 来实现更新背景的效果。

android. Widget. RemoteViews ActionException:android.widget.RemoteViews ActionException: view: android. Widget. LinearLayout doesn't have method: setImageBitmap (class android. Graphics. Bitmap)

Widget 动画不支持

Widget 里面还要加动画。由于 RemoteViews 里面不能实现正常的 View 动画,所以,Widget 里面的动画基本都是通过类似「帧动画」的方式来实现的,即将动画抽成一帧一帧的图,然后通过 Animator 来进行切换,从而实现动画效果,群友给出了一篇比较好的实践,大家可以参考参考,我就不卷了。

Widget 的动画实现

自定义 View 的支持

将要绘制的的内容,通过 bitmap 承载,绘制好后传递给 RemoteViews。

private fun drawCanvas(remoteViews: RemoteViews, index: Int) {
    val width = context.resources.getDimensionPixelSize(R.dimen.widget_canvas_width)
    val height = context.resources.getDimensionPixelSize(R.dimen.widget_canvas_height)
    val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
    val canvas = Canvas(bitmap)
    val paint = Paint().apply {
        this.color = Color.argb(115, 194, 108, 57)
        this.strokeWidth = 2f
        this.style = Paint.Style.STROKE
    }
 canvas.drawLine(0f, height/2f, width.toFloat(), height/2f, paint)
 remoteViews.setImageViewBitmap(
        R.id.widget_canvas, bitmap)
}

Android-自定义桌面小部件【搞定小米MIUI小部件】 - 掘金

TextView 问题

字体问题

小组件没有设置字体的 API,android:fontFamily="@font/raidercrusader3d" xml 设置也不支持

TextView 富文本问题

在 Android 中,可以使用 RemoteViews 来更新 App Widget 的视图。但是,由于 RemoteViews 的限制,无法直接使用 SpannableStringsetSpan 方法来更新 TextView 中部分文本的样式。

解决 1:
使用 HTML 实现文本样式更改

解决 2:
多个 TextView 组装

货币格式化问题

¥1,234

在三星 S21 手机展示的是 ¥1.234

原因: 写死了高度,逗号裁剪成了点

Rounded corners 圆角

Android 12 及以上

Android 12 引入了以下系统参数来设置 widget 圆角的半径:

以下示例显示了一个小部件,该小部件使用 system_app_widget_background_radius 作为小部件的角,使用 system_app_widget_inner_radius 作为小部件内部的视图。

圆角的重要注意事项:

Android 12 以下

为了使 widget 与以前版本的 Android 兼容,我们建议定义自定义属性并使用自定义主题来覆盖 Android 12 的这些属性,如以下示例 XML 文件所示:

<resources>
  <attr name="backgroundRadius" format="dimension" />
</resources>
<resources>
  <style name="MyWidgetTheme">
    <item name="backgroundRadius">@dimen/my_background_radius_dimen</item>
  </style>
</resources>
<resources>
  <style name="MyWidgetTheme" parent="@android:style/Theme.DeviceDefault.DayNight">
    <item name="backgroundRadius">@android:dimen/system_app_widget_background_radius</item>
  </style>
</resources>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
  android:shape="rectangle">
  <corners android:radius="?attr/backgroundRadius" />
  ...
</shape>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
  ...
  android:background="@drawable/my_widget_background" />

圆角不显示问题

别羡慕苹果的小部件了,安卓也有! - 掘金

圆角显示不完整

一加ACE2V 手机,自动圆角裁剪的不对,代码如下:

val bg = BitmapFactory.decodeResource(
	context.resources,
	R.drawable.bg_add_item_coupon_top
)
setImageBitmap(R.id.iv_main_bg, bg)

解决: 手动加上圆角,区分 Android12 和 Android12 以下

<!--values/dimens-->
<dimen name="app_widget_background_radius_dimen">21dp</dimen>

<!--values-v31/dimens-->
<dimen name="app_widget_background_radius_dimen">@android:dimen/system_app_widget_background_radius</dimen>

<!--背景 drawable:-->
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="rectangle">
    <corners android:radius="@dimen/app_widget_background_radius_dimen" />
    <solid android:color="@color/app_widget_search_bg_default" />
</shape>

注意: Android12 外边框的圆角,默认是 16dp,有的是 21dp,Android 14 是 28dp,怎么选?

<!-- System-provided radius for the background view of app widgets. The resolved value of this resource may change at runtime. -->
<dimen name="system_app_widget_background_radius">16dp</dimen>
<!-- System-provided radius for inner views on app widgets. The resolved value of this resource may change at runtime. -->
<dimen name="system_app_widget_inner_radius">8dp</dimen>
<!-- System-provided padding for inner views on app widgets. The resolved value of this resource may change at runtime. @removed -->
<dimen name="__removed_system_app_widget_internal_padding">16dp</dimen>

网络图片圆角处理的不对

问题 1:圆角显示不对

分析: view 的尺寸是 155dp x 155dp,图片的尺寸是 1697x388,scaleType 是 ScaleType.FIT_XY

通过 Fresco 下载后,处理成圆角后,再通过 FIT_XY 设置到 View 上就展示出来上面的效果

解决: 不用 Fresco 的 RoundedCornerPostprocessor 处理圆角,而是先下载好图片得到 Bitmap,先按指定的 width 和 height 缩放图片,再裁剪圆角,这样就可以避免不是 ImageView 比例的图片经过这样处理后,也能很好的展示,并且圆角显示也很完整

object BitmapUtils {  
    fun resizeAndCornerBitmap(bitmap: Bitmap, w: Int, h: Int, radius: Float): Bitmap {  
        return createCornerBitmap(resizeImage(bitmap, w, h), radius)  
    }  
    private fun resizeImage(bitmap: Bitmap, w: Int, h: Int): Bitmap {  
        val width = bitmap.getWidth()  
        val height = bitmap.getHeight()  
        val scaleWidth = w.toFloat() / width  
        val scaleHeight = h.toFloat() / height  
        val matrix = Matrix()  
        matrix.postScale(scaleWidth, scaleHeight)  
        // if you want to rotate the Bitmap  
        // matrix.postRotate(45);        return Bitmap.createBitmap(  
            bitmap, 0, 0, width,  
            height, matrix, true  
        )  
    }  
    private fun createCornerBitmap(bitmap: Bitmap, radius: Float): Bitmap {  
        // 给bitmap添加圆角  
        val output = Bitmap.createBitmap(bitmap.width, bitmap.height, Bitmap.Config.ARGB_8888)  
        output.eraseColor(Color.TRANSPARENT)  
        val outputCanvas = Canvas(output)  
        val paint = Paint()  
        paint.isAntiAlias = true  
        paint.shader = BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)  
  
        if (radius != 0f) {  
            val rect = RectF(0f, 0f, bitmap.width.toFloat(), bitmap.height.toFloat())  
            outputCanvas.drawRoundRect(rect, radius, radius, paint)  
        }  
        return output  
    }  
}
问题 2:图片裁剪导致内容过于靠边

问题效果:

问题效果代码:

internal suspend fun RemoteViews?.setRoundedImageBitmap(
    context: Context,
    @IdRes viewId: Int,
    @DrawableRes bgResource: Int,
    width: Int,
    height: Int,
    radius: Float = appWidgetBgRadius
): RemoteViews? {
    if (this == null) {
        return null
    }
    try {
        // 解析资源图片
        val drawable = ContextCompat.getDrawable(context, bgResource)
        drawableToCornerBitmap(
            drawable,
            width,
            height,
            radius,
            radius,
            radius,
            radius
        )?.let { bitmap ->
            setImageBitmap(viewId, bitmap)
        }
    } catch (e: Exception) {
        // ignore
        L.printStackTrace(e)
    }
    return this
}
suspend fun drawableToCornerBitmap(
    drawable: Drawable?,
    width: Int,
    height: Int,
    leftTopRadius: Float = 0f,
    rightTopRadius: Float = 0f,
    leftBottomRadius: Float = 0f,
    rightBottomRadius: Float = 0f
): Bitmap? {
    // suspendCancellableCoroutine try catch 会导致异常被吞掉,所以需要在内部处理异常
    var tempBitmap: Bitmap? = null
    var bitmap: Bitmap? = null
    return runCatching {
        suspendCancellableCoroutine<Bitmap?> { continuation ->

            drawable ?: return@suspendCancellableCoroutine continuation.resume(null)
            // 根据指定的宽高创建一个空的Bitmap
            tempBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
            tempBitmap?.let { origin ->
                // 使用Canvas和上面创建的Bitmap初始化,以便Drawable能够把自己绘制到Bitmap上
                val canvas = Canvas(origin)

                // 设置Drawable的边界
                drawable.setBounds(0, 0, width, height)

                // 将Drawable绘制到Canvas上,进而绘制到Bitmap上
                drawable.draw(canvas)
                origin.takeIf { !it.isRecycled }?.also {
                    // 给bitmap添加圆角
                    bitmap =
                        Bitmap.createBitmap(origin.width, origin.height, Bitmap.Config.ARGB_8888)
                    bitmap?.eraseColor(Color.TRANSPARENT)
                    val outputCanvas = Canvas(bitmap!!)
                    val paint = Paint()
                    paint.isAntiAlias = true
                    paint.shader =
                        BitmapShader(origin, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
                    // 创建Path对象
                    val path = Path()
                    val rect = RectF(0f, 0f, origin.width.toFloat(), origin.height.toFloat())

                    // 添加带圆角的矩形到路径
                    val radii = floatArrayOf(
                        leftTopRadius, leftTopRadius,   // 左上角
                        rightTopRadius, rightTopRadius,                       // 右上角
                        rightBottomRadius, rightBottomRadius,                       // 右下角
                        leftBottomRadius, leftBottomRadius                        // 左下角
                    )
                    path.addRoundRect(rect, radii, Path.Direction.CW)

                    // 裁剪画布,只在路径定义的区域绘制
                    outputCanvas.clipPath(path)

                    // 绘制原始的Bitmap到裁剪后的画布上
                    outputCanvas.drawBitmap(origin, 0f, 0f, null)
                    bitmap?.let {
                        continuation.resume(it.copy(it.config, true))
                    } ?: run {
                        continuation.resume(null)
                    }
                } ?: continuation.resumeWithException(Throwable("the bitmap is recycled"))
            }
            continuation.invokeOnCancellation {
                bitmap?.recycle()
                tempBitmap?.recycle()
            }
        }
    }.onFailure {
        L.e("drawableToCornerBitmap failed ${it.message}", it)
        bitmap?.recycle()
        tempBitmap?.recycle()
    }.getOrNull()
}

// 主背景圆角处理
setRoundedImageBitmap(
	context = context,
	viewId = R.id.iv_main_bg,
	bgResource = R.drawable.ic_appwidget_promotion_doudi,
	width = width(),
	height = height(),
	radius = appWidgetBgRadius
)

<ImageView  
    android:id="@+id/iv_main_bg"  
    android:layout_width="match_parent"  
    android:layout_height="wrap_content"  
    android:adjustViewBounds="true"  
    android:scaleType="centerCrop"  
    android:src="@drawable/ic_appwidget_promotion_doudi"  
    tools:ignore="ContentDescription" />

解决思路:

代码:

val bm = BitmapUtils.decodeBitmapFromResource(
	context.resources,
	R.drawable.ic_appwidget_promotion_doudi,
	width(),
	height()
)
BitmapUtils.resizeAndCornerBitmap(
	bm,
	width(),
	height(),
	appWidgetBgRadius
).let {
	setImageViewBitmap(R.id.iv_main_bg, it)
}

object BitmapUtils {

    fun resizeAndCornerBitmap(bitmap: Bitmap, w: Int, h: Int, radius: Float): Bitmap {
        return createCornerBitmap(resizeImage(bitmap, w, h), radius)
    }

    private fun resizeImage(bitmap: Bitmap, w: Int, h: Int): Bitmap {
        val width = bitmap.getWidth()
        val height = bitmap.getHeight()
        val scaleWidth = w.toFloat() / width
        val scaleHeight = h.toFloat() / height
        val matrix = Matrix()
        matrix.postScale(scaleWidth, scaleHeight)
        // if you want to rotate the Bitmap
        // matrix.postRotate(45);
        return Bitmap.createBitmap(
            bitmap, 0, 0, width,
            height, matrix, true
        )
    }

    private fun createCornerBitmap(bitmap: Bitmap, radius: Float): Bitmap {
        // 给bitmap添加圆角
        val output = Bitmap.createBitmap(bitmap.width, bitmap.height, Bitmap.Config.ARGB_8888)
        output.eraseColor(Color.TRANSPARENT)
        val outputCanvas = Canvas(output)
        val paint = Paint()
        paint.isAntiAlias = true
        paint.shader = BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)

        if (radius != 0f) {
            val rect = RectF(0f, 0f, bitmap.width.toFloat(), bitmap.height.toFloat())
            outputCanvas.drawRoundRect(rect, radius, radius, paint)
        }
        return output
    }


    fun decodeBitmapFromResource(
        res: Resources,
        resId: Int,
        reqWidth: Int,
        reqHeight: Int
    ): Bitmap {
        // 第一次解析将inJustDecodeBounds设置为true,目的是从图片中取宽高
        val options = BitmapFactory.Options().apply {
            inJustDecodeBounds = true
        }

        BitmapFactory.decodeResource(res, resId, options)

        // 计算inSampleSize
        options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight)

        // 使用获取到的inSampleSize再次解析图片
        options.inJustDecodeBounds = false
        return BitmapFactory.decodeResource(res, resId, options)
    }

    private fun calculateInSampleSize(
        options: BitmapFactory.Options,
        reqWidth: Int,
        reqHeight: Int
    ): Int {
        // 原始图片的宽高
        val (height: Int, width: Int) = options.run { outHeight to outWidth }
        var inSampleSize = 1

        if (height > reqHeight || width > reqWidth) {
            val halfHeight: Int = height / 2
            val halfWidth: Int = width / 2

            while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) {
                inSampleSize *= 2
            }
        }

        return inSampleSize
    }
}
<ImageView  
    android:id="@+id/iv_main_bg"  
    android:layout_width="match_parent"  
    android:layout_height="wrap_content"  
    android:adjustViewBounds="true"  
    android:scaleType="fitXY"  
    android:src="@drawable/ic_appwidget_promotion_doudi"  
    tools:ignore="ContentDescription" />

**解决后效果:

圆角适配处理原则

小组件 RTL 适配

Widget 预览问题

预览图片大小问题

android:previewImage

示例:不同预览图片大小效果

长图会被裁剪,从上到下开始裁剪

预览图片选择: 尽可能大,长图会被裁剪

预览布局 (Android 12 及 +)

previewLayout 和 previeImage 同时存在

android:previewLayout

预览布局的尺寸是什么?

预览布局和预览图片同时存在,优先预览布局

xml-v31 只设置了 previewLayout,展示的还是 App icon

OPPO Android13 ColorOS 13.1:

解决:xml-v31appwidget-provider 中同时添加 previewImagepreviewLayout,解决部分魔改系统不兼容只设置 previewLayout 的问题

Widget 预览不见了

如下配置在 Google Pixel Fold 添加小组件展示不出来:

<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
    android:description="@string/SHEIN_KEY_APP_22196"
    android:initialLayout="@layout/layout_app_widget_search_tool"
    android:minWidth="359dp"
    android:minHeight="120dp"
    android:previewLayout="@layout/layout_app_widget_search_tool"
    android:targetCellWidth="5"
    android:targetCellHeight="2"
    android:updatePeriodMillis="0"
    android:widgetCategory="home_screen" />

问题: 在 Google Fold 手机,minWidth 设置的太大了,在添加小组件的入口,找不到该小组件

解决: minWidget 设置的小一点,改成 300dp即可

桌面长按 app widget 图标,裁剪了

原因: 在国内一加 Ace 2V 2x2 小组件的尺寸 (150x150dp) 过大了,显示不下来

解决: 小组件的宽高指定小点,改成 130x130dp

解决后:

事件处理

点击事件如何处理?

1 个 widget 内,多个 view 的点击事件处理

多个 widget 时,widget 内的 button 点击事件处理

只有最后一个设置了 click 事件的 widget 才响应?

广播也是同一个?

通过广播添加的点击事件无效

在用 setOnClickPendingIntent() 方法设置点击事件发送 Broadcast 时,不能直接 new Intent(action)

Intent intent = new Intent(ACTION_CLICK_UPDATE_WIDGET);
views.setOnClickPendingIntent(R.id.widget_layout, PendingIntent.getBroadcast(context, 0, intent, 0));

上面这种写法在应用退出后在有些手机上收不到广播,然后换成下面这种写法:

Intent layout = new Intent(context, GoldWidgetProvider.class);
layout.setAction(ACTION_CLICK_UPDATE_WIDGET);
views.setOnClickPendingIntent(R.id.widget_layout, PendingIntent.getBroadcast(context, 0, layout, 0));

Widget 添加的点击事件失效

问题 1:多个 widget 点击事件失效

问题: 先后添加 2 个 widget,给 1 个按钮添加点击事件,存在 1 个 widget 可以点击按钮,另外 1 个点击不了按钮。然后滑动屏幕到另外一屏,再滑回来,2 个 widget 又可以点击了

问题手机: OnePlusAce2V 国行

问题 2:部分手机点击事件失效

  1. nova 9 手机设置的点击事件失效,点击 widget 直接拉起 app
  2. 部分手机点击事件失效,点击 widget 无响应

问题 3:强制 kill 进程

点击小组件无效,pixel 6

问题分析

原因 1:setOnClickPendingIntent 被覆盖掉

RemoteViews full update 设置了 setOnClickPendingIntent ,后续的更新把 setOnClickPendingIntent 给丢失了,导致点击事件失效

原因 2:onUpdate 执行 2 次

首次添加 widget,AppWidgetProvider 的 onUpdate 会执行 2 次,如果第 2 次 onUpdate 更新判断了不更新 widget,可能导致 setOnClickPendingIntent 失效导致点击事件无效。

触摸事件?

不支持触摸事件

正方形图片

因为受限于 RemoteViews 的缘故,想要实现正方形的小部件可没那么容易,以至于一些主流应用的 APP Widget 也不是一个完美的正方形。

面小部件在桌面所占的空间受 minWidthminHeighttargetCellWidthtargetCellHeight 共同影响。

因为我们设置的 targetCellWidthtargetCellHeight 都是 2,所以会占用桌面 2x2 的空间。

但是 2x2 的空间可能只有 100dpx150dp

因为 View 的大小大于桌面空间的大小,所以最终会被缩小,缩小到能展示得下的宽高。

直接代码写死?

<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="100dp"
    android:layout_height="100dp"
    android:background="@color/white">
    <TextView />
</FrameLayout>

从结果可以看到,虽然是正方形,但是宽度却没有充满整个空间,也就是说,我们浪费了一些空间。

ImageViewsetAdjustViewBounds

// #ImageView
/**
 * 如果您希望ImageView调整其边界以保持drawable宽高比,则将其设置为 true
 */
public void setAdjustViewBounds(boolean adjustViewBounds) {
	// ···
}
// 将ImageView的adjustViewBounds设置true,他就会调整自己的宽高,让他的宽高比跟drawable的宽高保持一致。

代码:

<ImageView xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_gravity="center"
    android:adjustViewBounds="true"
    android:background="@color/teal_200"
    android:src="@drawable/square_white" />

数据更新问题

网络请求放哪?

尽量不要直接在 AppWidgetProvider 中进行网络请求,和耗时操作。onReceive() 10 秒内得做完。

如何更新 widget 数据

定时刷新是 widget 最核心的问题,启动后台服务 的方式已经过时了,Android8 之后,后台服务限制越来越严格,在主 App 被杀死的情况下,已经连偷偷启动后台服务都做不到了,什么守护线程,什么广播唤起,都不管用了

方案:

  1. Widget 自带:updatePeriodMillis
  2. 前台服务
  3. WorkManager:在 AppWidgetProvideronEnabled 时,安排一个 periodic 任务;在 onDisabled 时,取消 periodic 任务

应用内主动更新 Widget

当 App 活着的时候,可以主动来更新 Widget,而且有两种方式可以实现,一种是通过广播 ACTION_APPWIDGET_UPDATE,触发 Widget 的 update 回调,从而进行更新:

val manager = AppWidgetManager.getInstance(this)
val ids = manager.getAppWidgetIds(ComponentName(this, XXXWidget::class.java))
val updateIntent = Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE)
updateIntent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids)
sendBroadcast(updateIntent)

本质就是发送更新的广播,除此之外,还可以使用 AppWidgetManager 来直接对 Widget 进行更新,代码如下。

val remoteViews = RemoteViews(context.packageName, R.layout.xxx)
val appWidgetManager = AppWidgetManager.getInstance(context)
val componentName = ComponentName(context, XXXWidgetProvider::class.java)
appWidgetManager.updateAppWidget(componentName, remoteViews)

这种方式就是通过 AppWidgetManager 来对指定的 Widget 进行修改,使用新的 RemoteViews 来更新当前 Widget。

应用外被动更新 Widget

1、自带的 updatePeriodMillis 属性

updatePeriodMillis 属性介绍

updatePeriodMillis 用于设置应用小部件自动更新的时间间隔。具体来说,它指定了系统多长时间会调用一次 AppWidgetProvideronUpdate 方法,从而更新小部件的内容。

updatePeriodMillis 属性定义了 App Widget 框架调用 onUpdate() 回调方法请求 AppWidgetProvider 更新的频率。使用该值时,实际更新并不能保证完全按时进行,因此我们建议更新频率尽可能低 -- 也许每小时不超过一次,以节省电池。您也可以允许用户在配置中调整频率 -- 有些人可能希望股票行情每 15 分钟更新一次,或者一天只更新四次。
注意:如果设备在更新时间(如 updatePeriodMillis 所定义)处于睡眠状态,那么设备将唤醒以执行更新。如果每小时更新不超过一次,这可能不会对电池寿命造成重大影响。但是,如果您需要更频繁地更新和/或您不需要在设备睡眠时更新,那么您可以根据 alarm 执行更新,这样就不会唤醒设备。为此,请使用 AlarmManager 通过 AppWidgetProvider 接收到的 Intent 设置闹钟。将闹钟类型设置为 ELAPSED_REALTIME 或 RTC,只有当设备处于唤醒状态时才会发出闹钟。然后将 updatePeriodMillis 设为零("0")。
目前推荐用 WorkManager

<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
    android:initialLayout="@layout/appwidget_layout"
    android:updatePeriodMillis="1800000"  <!-- 更新频率:30分钟 (30 * 60 * 1000 毫秒) -->
    android:widgetCategory="home_screen">
</appwidget-provider>
onUpdate() 中做网络请求?

AppWidgetProvideronUpdate 方法中执行网络请求虽然技术上是可行的,但需要注意以下几点:

  1. 耗时操作onUpdate 方法在主线程(UI 线程)上运行,因此如果在此方法中执行耗时操作(如网络请求),可能会导致应用响应变慢或 ANR(应用无响应)错误。
  2. 电池消耗:频繁的网络请求会消耗更多的电量,不利于用户体验。
  3. 频率限制:小部件更新的频率受系统限制,即使设置较短的更新间隔,实际次数可能少于预期。

由于 AppWidgetProvider 继承处于 BroadcastReceiver, 生命周期非常短, 如果需要执行耗时操作会发生 ANR 异常, 因此我们可以在 onUpdate() 方法中启动 Service, 然后在 Service 数据处理,建议在 onEnable() 启动服务,onDisable() 关闭 Service

在 AppWidgetProvider 内开启后台服务执行耗时/异步操作已经行不通,在高版本 Android 上,主 App 没启动的情况下,不允许启动后台服务,只能启动前台服务。

为了在 onUpdate 方法中执行网络请求并避免上述问题,推荐使用 WorkManager 或其他异步任务执行框架(如 AsyncTask 或 Kotlin 协程)。

updatePeriodMillis 无效

现象:
配置了 updatePeriodMillis 属性但不生效。

原因:
部分厂商引入了自己的一套规则来限制后台处理,并在某些情况下完全忽略此属性,以人为地节省一些电池寿命。

存在问题的设备:

没有问题

解决:
用 WorkManager

2、前台服务

使用前台服务,需要自己维护前台服务的保活,当然由于是前台服务,就几乎不会被杀死,即使被杀死,根据 onStartCommand() 的返回值设置,服务仍然可以在资源充足的条件下立即重启。

这个方案并不是完美方案,难度在于前台服务的保活。比如说在前台服务被杀死时,重新启动自己;主 App 启动/运行时,检查前台服务;在 widget 上提供刷新按钮,让用户可以主动刷新。前台服务的优缺点如下:

优点

  1. 定时任务较稳定,大部分情况下能正常运行。
  2. 刷新间隔想设多少就多少,适用于对刷新十分频繁的应用,如时钟天气类应用。

缺点

  1. 会增大应用的耗电量
  2. 会在通知栏里显示服务且无法移除该通知

3、WorkManager

优点

  1. 定时任务稳定,App 被杀死也能正常执行任务
  2. 实现简单,解决了 widget 在 App 不存活时的数据刷新问题,是后台服务的替代者

缺点

使用:

Application 中的初始化问题

当 widget 刷新时,如果应用没有处于开启状态下,这时会创建 APP 进程并初始化 Application,之后回调 widget 的 onUpdate 方法。然而这里会有一个问题,由于部分 app 为了性能优化,将部分初始化操作移动到了引导页或 Main 页面里了,这样当 widget 想使用某些功能时,由于只创建了 Application,在引导页或 main 页面里进行初始化的那部分功能没有进行初始化,便会抛出各种异常。所以这里开发的时候需要重点检查一遍。

脱离 APP 更新数据

添加了多个 widget,数据更新问题

极端情况

没有网络

首次打开,没有网络

APP 清除数据后,现有的 widget 和后新增的 widget

重启后

其他

语言切换

动态权限问题

在 App Widget 中无法动态获取权限

Widget 中能否获取运行时权限?

不能,请求权限需要传递 activity 参数;在 APP Widget 中获取不到 activity

ActivityCompat.requestPermissions(
	this,
	arrayOf(
		Manifest.permission.ACCESS_FINE_LOCATION,
		Manifest.permission.ACCESS_COARSE_LOCATION
	),
	PERMISSION_REQUEST_LOCATION
)

定位问题

在 Android10 及以上在后台获取定位信息需要 android.permission.ACCESS_BACKGROUND_LOCATION 权限;在 App widget 中,App 不在前台,在后台,所以要在 App widget 中获取定位信息,是需要加上该权限的。

还存在其它问题:

  1. 隐私合规问题
  2. App 下架风险

重启后

埋点

PendingIntent 问题

Android 12 PendingIntent flag 问题

在 Android 12(API 31, 即版本 S)及以上,创建 PendingIntent 时需要明确指定 FLAG_IMMUTABLEFLAG_MUTABLE 标记。这是为了确保平台可以更好地管理和理解 PendingIntent 的使用场景。

FLAG_IMMUTABLE 表示 PendingIntent 的内容不能被修改,而 FLAG_MUTABLE 则允许它在传递过程中被修改。大多数情况应该使用 FLAG_IMMUTABLE,除非你有特定的需求需要 PendingIntent 是可变的。

示例:

private fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int) {
	val views = RemoteViews(context.packageName, R.layout.appwidget_layout)
	// 创建点击事件的Intent
	val intent = Intent(context, MyAppWidgetProvider::class.java).apply {
		action = BUTTON_CLICK_ACTION
	}
	val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
		PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
	} else {
		PendingIntent.FLAG_UPDATE_CURRENT
	}
	val pendingIntent = PendingIntent.getBroadcast(context, 0, intent, flags)
	// 将点击事件关联到Button
	views.setOnClickPendingIntent(R.id.button, pendingIntent)
	// 更新App Widget
	appWidgetManager.updateAppWidget(appWidgetId, views)
}

改了 click action

原有的 app widget,点击事件就失效了

PendingIntent 相同的问题

如果 action 不变,只是往 Intent 的 putExtra 不一样,会被认为是同一个 PendingIntent,要想复用同一个 action 来区分不同的点击事件,就需要加上其他的来区分。

HarmonyOS 系统 click 事件 点击无效

报错信息:

Background activity start [callingPackage: com.zzkko; callingUid: 11092; isCallingUidForeground: false; callingUidHasAnyVisibleWindow: false; callingUidProcState: RECEIVER; isCallingUidPersistentSystemProcess: false; realCallingUid: 11092; isRealCallingUidForeground: false; realCallingUidHasAnyVisibleWindow: false; realCallingUidProcState: RECEIVER; isRealCallingUidPersistentSystemProcess: false; originatingPendingIntent: null; isBgStartWhitelisted: false; intent: Intent { act=android.intent.action.VIEW dat=sheinlink://shein.com/order/order_list?data={"page_from":"crm_widget","url_from":"widget-widget_middle_logistics-andshus-en-O-DOUDI","need_login":"true"} flg=0x10000000 cmp=com.zzkko/com.shein.welcome.WelcomeActivity }; callerApp: ProcessRecord{8cb016 2579:com.zzkko/u0a1092}; shouldAbortSelfLaunchWhenReturnHome: false]

版本:

解决 1:后台弹窗,勾选该权限

解决 2:点击事件不用广播,直接设置 PendingIntent 的 Activity

internal inline fun <reified P : AppWidgetProvider> RemoteViews?.setLogisticsItemOnclickPending(
    context: Context,
    actionName: String,
    @IdRes viewId: Int,
    deeplink: String?,
    defaultDeeplink: String = LOGISTICS_DEEPLINK_DOUDI_2X2
) {
    val link = deeplink?.takeIf { it.isNotEmpty() && it != "null" } ?: defaultDeeplink
//    this.apply {
//        val extra = Bundle()
//        extra.putString(WidgetConstants.EXTRA_DEEPLINK, link)
//        setOnclickPending<P>(
//            context = context,
//            viewId = viewId,
//            actionName = actionName,
//            extra = extra,
//            requestCode = 0
//        )
//    }
	// 改成下面这种,不用权限
    val intentParser = Uri.parse(link)
    val intent = Intent(Intent.ACTION_VIEW, intentParser)
    val pendingIntent =
        PendingIntent.getActivity(context, 200, intent, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { // Android12
            PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
        } else {
            PendingIntent.FLAG_UPDATE_CURRENT
        })
    this?.setOnClickPendingIntent(viewId, pendingIntent)
}

解决 3:代码申请权限

安卓代码权限 AndroidManifest.xml 设置:uses-permission android:name=“android.permission.SYSTEM_ALERT_WINDOW”

// 检查是否已经授予悬浮框权限
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
	if (!Settings.canDrawOverlays(this)) {
		//若未授权则请求权限
		getOverlayPermission();
		finish();
		return;
	}
} else {
	boolean pFlag = FloatWindowManager.getInstance().requestPermission(context);
	if (!pFlag) {
		return;
	}
}

Ref:

BroadcastReceiver 中 (后台) 启动 Activity

在后台比如 BroadcastReceiver service 中启动 Activity 需要注意一下几点

Intent Flag

Intent intent = new Intent(); intent.setClassName(context, "class name"); intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);

权限,任何在后台 startActivity 的操作都将会延迟 5 秒

注:系统应用才有权限添加 在 AndroidManifest.xml 中添加 "android.permission.STOP_APP_SWITCHES" 权限;

对于非系统应用,StackOverFlow 有如下解决方案:

Intent intent = new Intent(context, A.class);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
context.startActivity(intent);

// 改为:
Intent intent = new Intent(context, A.class);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, 0);
try {
  pendingIntent.send();
} catch (PendingIntent.CanceledException e) {
  e.printStackTrace();
}

较难适配手机

手机型号 原因 备注
Galaxy S21 小组件经常展示不出来,需要单独做套小尺寸的 Android 14
OnePlusAce2V 国行 尺寸小,Android12 也没有系统裁剪圆角 Android 12
Galaxy A51 小组件尺寸很大,写死尺寸的话经常很多空白 Android 12
nova 9 点击 PendingIntent 拉起 Activity 需要后台弹窗权限 HarmonyOS 2.0.1
Galaxy A32 放在 drawable-nodpi 目录下的预览图,很大 100 解决:放到 drawable-xxxhdpi

Android 11